package com.xuecheng.media.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.crypto.digest.MD5;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.j256.simplemagic.ContentInfoUtil;
import com.j256.simplemagic.ContentType;
import com.xuecheng.base.constant.*;
import com.xuecheng.base.model.PageParams;
import com.xuecheng.base.model.PageResult;
import com.xuecheng.base.result.RestResponse;
import com.xuecheng.base.util.MediaTypeUtil;
import com.xuecheng.media.config.MinioProperties;
import com.xuecheng.media.exception.*;
import com.xuecheng.media.mapper.MediaFilesMapper;
import com.xuecheng.media.mapper.MediaMinioFilesMapper;
import com.xuecheng.media.mapper.MediaProcessMapper;
import com.xuecheng.media.model.dto.QueryMediaParamsDTO;
import com.xuecheng.media.model.dto.UploadFileResultDTO;
import com.xuecheng.media.model.po.MediaFiles;
import com.xuecheng.media.model.po.MediaMinioFiles;
import com.xuecheng.media.model.po.MediaProcess;
import com.xuecheng.media.service.IMediaFilesService;
import io.minio.*;
import io.minio.errors.ErrorResponseException;
import io.minio.messages.Item;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.FilterInputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * <p>
 * 媒资信息 服务实现类
 * </p>
 *
 * @author 勾新杰
 * @since 2025-01-16
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class MediaFilesServiceImpl extends ServiceImpl<MediaFilesMapper, MediaFiles> implements IMediaFilesService {

    /**
     * 媒资文件mapper
     */
    private final MediaFilesMapper mediaFilesMapper;

    /**
     * 媒体MINIO分块文件mapper
     */
    private final MediaMinioFilesMapper mediaMinioFilesMapper;

    /**
     * 媒体处理mapper
     */
    private final MediaProcessMapper mediaProcessMapper;

    /**
     * minio配置属性
     */
    private final MinioProperties minioProperties;

    /**
     * minio客户端
     */
    private final MinioClient minioClient;

    /**
     * 媒资文件分页查询
     *
     * @param pageParams          分页参数
     * @param queryMediaParamsDTO 查询条件
     * @return 查询结果
     */
    @Override
    public PageResult<MediaFiles> queryMediaFiles(PageParams pageParams, QueryMediaParamsDTO queryMediaParamsDTO) {
        Page<MediaFiles> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
        Page<MediaFiles> selectPage = lambdaQuery()
                .eq(StrUtil.isNotBlank(queryMediaParamsDTO.getAuditStatus()),
                        MediaFiles::getAuditStatus,
                        queryMediaParamsDTO.getAuditStatus())
                .eq(StrUtil.isNotBlank(queryMediaParamsDTO.getFileType()),
                        MediaFiles::getFileType,
                        queryMediaParamsDTO.getFileType())
                .like(StrUtil.isNotBlank(queryMediaParamsDTO.getFilename()),
                        MediaFiles::getFilename,
                        queryMediaParamsDTO.getFilename())
                .page(page);
        return new PageResult<>(selectPage.getRecords(), selectPage.getTotal(), pageParams.getPageNo(), pageParams.getPageSize());
    }

    /**
     * 根据文件生成md5值
     *
     * @param file 文件
     * @return md5值
     */
    private String md5Hex(MultipartFile file) throws IOException {
        return DigestUtil.md5Hex(file.getInputStream());
    }

    /**
     * 根据文件组装再minio中要存储的路径
     *
     * @param file 文件
     * @return 文件路径
     * @throws IOException 文件流异常
     */
    private String getFilePath(MultipartFile file) throws IOException {
        // 1. 根据当前日期生成目录名称，如：2023/7/12
        LocalDate currDate = LocalDate.now();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        String directory = currDate.format(formatter).replace("-", "/") + "/";
        // 2. 根据文件md5值生成文件名称
        String fileName = md5Hex(file);
        // 3. 获取原始文件后缀名
        String originalFilename = file.getOriginalFilename();
        if (originalFilename == null) throw new IllegalArgumentException();
        String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
        // 4. 文件名称拼接
        return directory + fileName + extension;
    }

    /**
     * 将文件保存到minio中
     *
     * @param file     文件
     * @param bucket   桶名称
     * @param filePath 存储到minio中的路径
     * @throws Exception minio异常
     */
    private void saveChunkFileToMinio(MultipartFile file, String bucket, String filePath) throws Exception {
        saveToMinio(file, bucket, filePath, file.getContentType());
    }

    /**
     * 将文件保存到minio中
     *
     * @param file     文件
     * @param bucket   桶名称
     * @param filePath 存储到minio中的路径
     * @param mimeType 文件类型
     * @throws Exception minio异常
     */
    private void saveToMinio(MultipartFile file, String bucket, String filePath, String mimeType) throws Exception {
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucket)
                        .object(filePath)
                        .stream(file.getInputStream(), file.getSize(), -1)
                        .contentType(mimeType)
                        .build());
    }

    /**
     * 从minio中获取文件流
     *
     * @param bucket   桶名称
     * @param filePath 文件存储路径
     * @return 文件流
     * @throws Exception minio异常
     */
    private FilterInputStream getFromMinio(String bucket, String filePath) throws Exception {
        return minioClient.getObject(
                GetObjectArgs.builder()
                        .bucket(bucket)
                        .object(filePath)
                        .build());
    }

    /**
     * 获取文件列表
     *
     * @param bucket 桶名称
     * @param prefix 文件前缀（目录）
     * @return 文件列表
     */
    private Iterable<Result<Item>> listFiles(String bucket, String prefix) {
        return minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(bucket)
                        .prefix(prefix)
                        .recursive(true)
                        .build());
    }

    /**
     * 构建分块文件列表
     *
     * @param bucket   桶名称
     * @param filePath 分块文件存储路径
     * @return source
     */
    private ComposeSource buildSource(String bucket, String filePath) {
        return ComposeSource.builder()
                .bucket(bucket)
                .object(filePath)
                .build();
    }

    /**
     * 合并分块文件
     *
     * @param bucket   桶名称
     * @param filePath 文件存储路径
     * @param sources  分块文件列表
     */
    private void composeObject(String bucket, String filePath, List<ComposeSource> sources) throws Exception {
        minioClient.composeObject(
                ComposeObjectArgs.builder()
                        .bucket(bucket)
                        .object(filePath)
                        .sources(sources)
                        .build());
    }

    /**
     * 获取文件元数据
     *
     * @param bucket   桶名称
     * @param filePath 文件存储路径
     * @return 文件信息
     */
    private StatObjectResponse statObject(String bucket, String filePath) throws Exception {
        return minioClient.statObject(
                StatObjectArgs.builder()
                        .bucket(bucket)
                        .object(filePath)
                        .build());
    }

    /**
     * 从minio中删除文件
     *
     * @param bucket   桶名称
     * @param filePath 文件存储路径
     * @throws Exception minio异常
     */
    private void removeFromMinio(String bucket, String filePath) throws Exception {
        minioClient.removeObject(
                RemoveObjectArgs.builder()
                        .bucket(bucket)
                        .object(filePath)
                        .build());
    }

    /**
     * @param file     文件
     * @param md5      文件md5值
     * @param filePath 文件存储路径
     * @param bucket   桶名称
     */
    @Transactional
    public void saveToDatabase(MultipartFile file, String md5, String filePath, String bucket) {
        MediaFiles mediaFiles = MediaFiles.builder()
                .id(md5)
                .companyId(1232141425L) // TODO 此处需要从上下文中获取
                .companyName("传智教育")
                .fileId(md5)
                .filename(file.getOriginalFilename())
                .fileType(MediaTypeUtil.getMediaType(file))
                .fileSize(file.getSize())
                .filePath(filePath)
                .tags(MediaTagConstant.MEDIA_TAG_IMAGE)
                .bucket(bucket)
                .url("/" + bucket + "/" + filePath)
                .createDate(LocalDateTime.now())
                .auditStatus(CourseAuditStatusConstant.SUBMITTED)
                .build();
        int insert = mediaFilesMapper.insert(mediaFiles);
        if (insert <= 0) throw new IllegalArgumentException();
    }

    /**
     * 文件上传
     *
     * @param file 文件
     * @return 上传结果
     */
    @Override
    public UploadFileResultDTO upload(MultipartFile file, Long courseId) {
        try {
            String bucket = minioProperties.getBucket().getFiles();
            String filePath = courseId != null ? courseFilePath(courseId) : getFilePath(file);
            String md5 = md5Hex(file);
            // 1. 上传到minio中
            if (courseId == null) {
                saveToMinio(file, bucket, filePath, ContentInfoUtil.findExtensionMatch(filePath.substring(filePath.lastIndexOf("."))).getMimeType());
            } else {
                saveToMinio(file, bucket, filePath, ContentType.HTML.getMimeType());
            }
            // 2. 查询文件表，如果文件表里面有，则直接返回
            MediaFiles select = mediaFilesMapper.selectById(md5);
            // 2. 如果文件表里面没有，则添加到数据库中
            if (select == null) {
                MediaFilesServiceImpl proxy = (MediaFilesServiceImpl) AopContext.currentProxy();
                proxy.saveToDatabase(file, md5, filePath, bucket);
                select = mediaFilesMapper.selectById(md5);
            }
            // 3. 返回上传结果
            return BeanUtil.copyProperties(select, UploadFileResultDTO.class);
        } catch (Exception e) {
            throw new MediaUploadFailedException();
        }
    }

    /**
     * 获取课程文件路径
     *
     * @param courseId 课程id
     * @return 文件路径
     */
    private String courseFilePath(Long courseId) {
        return "course/" + courseId + ".html";
    }

    /**
     * 检查文件是否存在
     *
     * @param fileMd5 文件md5
     * @return 文件信息
     */
    @Override
    public RestResponse<Boolean> checkFile(String fileMd5) {
        // 1. 先在数据库中检查该文件是否存在
        MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
        if (mediaFiles != null) {
            try {
                // 2. 如果存在，则检查minio中
                String bucket = mediaFiles.getBucket();
                String object = mediaFiles.getFilePath();
                FilterInputStream response = getFromMinio(bucket, object);
                if (response != null) {
                    response.close();
                    return RestResponse.success(true);
                }
            } catch (ErrorResponseException e) {
                // 数据库中存在但是在minio中不存在
                if (e.errorResponse().code().equals("NoSuchKey"))
                    return RestResponse.success(false);
                    // 其他原因，不知道minio中是否有该文件
                else return RestResponse.error(e.getMessage());
            } catch (Exception e) { // 其他原因，不知道minio中是否有该文件
                return RestResponse.error(e.getMessage());
            }
        }
        // 3. 如果不存在，则返回false
        return RestResponse.success(false);
    }

    /**
     * 检查分块文件是否存在
     *
     * @param fileMd5 文件md5
     * @param chunk   文件分块序号
     * @return 文件信息
     */
    @Override
    public RestResponse<Boolean> checkChunk(String fileMd5, int chunk) {
        try {
            // 1. 获取分块文件的路径
            String chunkFilePath = getChunkFilePath(fileMd5, chunk);
            // 2. 获取分块文件
            FilterInputStream response = getFromMinio(minioProperties.getBucket().getVideoFiles(), chunkFilePath);
            if (response != null) {
                response.close();
                return RestResponse.success(true);
            }
        } catch (ErrorResponseException e) {
            // 如果minio中不存在，则返回false
            if (e.errorResponse().code().equals("NoSuchKey"))
                return RestResponse.success(false);
            else return RestResponse.error(e.getMessage());
        } catch (Exception e) {
            return RestResponse.error(e.getMessage());
        }
        // response为null，则返回false
        return RestResponse.success(false);
    }

    /**
     * 获取分块文件路径
     *
     * @param fileMd5 文件md5
     * @param chunk   文件分块序号
     * @return 文件路径
     */
    private String getChunkFilePath(String fileMd5, int chunk) {
        return getChunkFileDirectory(fileMd5) + "/" + chunk;
    }

    /**
     * 获取分块文件目录
     *
     * @param fileMd5 文件md5
     * @return 文件目录
     */
    private String getChunkFileDirectory(String fileMd5) {
        return fileMd5.charAt(0) + "/" + fileMd5.charAt(1) + "/" + fileMd5 + "/" + "chunk";
    }

    /**
     * 上传分块文件
     *
     * @param file    文件
     * @param fileMd5 文件md5
     * @param chunk   文件分块序号
     */
    @Override
    public RestResponse<String> uploadChunk(MultipartFile file, String fileMd5, int chunk) {
        try {
            // 1. 分块上传到minio中
            saveChunkFileToMinio(file, minioProperties.getBucket().getVideoFiles(), getChunkFilePath(fileMd5, chunk));
            // 2. 分块保存到数据库中
            MediaFilesServiceImpl proxy = (MediaFilesServiceImpl) AopContext.currentProxy();
            proxy.saveToDatabase(fileMd5, chunk, minioProperties.getBucket().getVideoFiles());
            return RestResponse.success();
        } catch (Exception e) {
            return RestResponse.error(e.getMessage());
        }
    }

    /**
     * 保存到数据库
     *
     * @param fileMd5    文件md5
     * @param chunk      分块序号
     * @param videoFiles 桶
     */
    @Transactional
    public void saveToDatabase(String fileMd5, int chunk, String videoFiles) {
        MediaMinioFiles mediaMinioFiles = MediaMinioFiles
                .builder()
                .fileId(fileMd5)
                .chunkNumber(chunk)
                .chunkPath(getChunkFilePath(fileMd5, chunk))
                .bucket(videoFiles)
                .createDate(LocalDateTime.now())
                .build();
        int insert = mediaMinioFilesMapper.insert(mediaMinioFiles);
        if (insert == 0) throw new IllegalArgumentException();
    }

    /**
     * 合并分块文件
     *
     * @param fileMd5    文件md5
     * @param fileName   文件名称
     * @param chunkTotal 分块文件总数
     * @return 合并结果
     */
    @Override
    public RestResponse<String> mergeChunks(String fileMd5, String fileName, Integer chunkTotal) {
        // return RestResponse.error("test"); // 测试清除在minio放了很久的分块文件
        // 1. 获取分块文件目录和桶
        String chunkFileDirectory = getChunkFileDirectory(fileMd5);
        String videoFiles = minioProperties.getBucket().getVideoFiles();
        // 2. 获取该目录下分块文件列表
        Iterable<Result<Item>> listObjects = listFiles(videoFiles, chunkFileDirectory);
        List<ComposeSource> sources = new ArrayList<>();
        // 3. 遍历分块文件列表，将分块文件添加到sources中
        for (Result<Item> object : listObjects) {
            try {
                sources.add(buildSource(videoFiles, object.get().objectName()));
            } catch (Exception e) {
                return RestResponse.error(e.getMessage());
            }
        }
        // 4. 检查分块文件总数是否匹配
        if (sources.size() != chunkTotal) return RestResponse.error(MediaError.CHUNK_TOTAL_NOT_MATCH.getErrMessage());
        String mergeFilePath = getMergeFilePath(fileMd5, fileName);
        // 5. 合并分块文件
        try {
            composeObject(videoFiles, mergeFilePath, sources);
        } catch (Exception e) {
            return RestResponse.error("合并分块文件失败");
        }
        try (FilterInputStream response = getFromMinio(videoFiles, mergeFilePath)) {
            // 7. 获取合并后的文件以及获取文件的元数据
            StatObjectResponse statObject = statObject(videoFiles, mergeFilePath);
            String md5 = MD5.create().digestHex(response);
            // 8. 比较文件md5，如果匹配，则保存到数据库、删除分块文件、如果不是mp4的视频文件，还要加入待处理任务，否则删除合并后的文件
            if (fileMd5.equals(md5)) {
                // 8.1 将合并文件保存到数据库，并删除数据库里面相关的分块文件，然后如果不是mp4的视频文件，还要加入待处理任务
                MediaFilesServiceImpl proxy = (MediaFilesServiceImpl) AopContext.currentProxy();
                proxy.saveToDatabase(statObject, fileMd5, fileName, videoFiles, chunkTotal);
                // 8.2 删除minio的分块文件
                clearChunkFilesMinio(fileMd5, chunkTotal);
                response.close();
                return RestResponse.success();
            }
            // 9. 如果不匹配，则删除合并后的文件
            else {
                try {
                    removeFromMinio(videoFiles, mergeFilePath);
                    response.close();
                } catch (Exception e) {
                    log.error("删除合并文件失败");
                    return RestResponse.error(e.getMessage());
                }
                return RestResponse.error(MediaError.MERGED_FILE_NOT_ENTIRE.getErrMessage());
            }
        } catch (Exception e) {
            log.error(e.getMessage());
            return RestResponse.error(e.getMessage());
        }
    }

    /**
     * 清除分块文件
     *
     * @param fileMd5    文件md5
     * @param chunkTotal 分块文件总数
     */
    private void clearChunkFilesMinio(String fileMd5, int chunkTotal) {
        // 1. 获取分块文件目录和桶
        String chunkFileDirectory = getChunkFileDirectory(fileMd5);
        String videoFiles = minioProperties.getBucket().getVideoFiles();
        // 2. 获取该目录下分块文件列表
        Iterable<Result<Item>> listObjects = listFiles(videoFiles, chunkFileDirectory);
        // 3. 遍历分块文件列表，将分块文件删除，并计数
        AtomicInteger count = new AtomicInteger(0);
        listObjects.forEach(itemResult -> count.getAndIncrement());
        if (count.get() != chunkTotal) throw new IllegalArgumentException();
        listObjects.forEach(
                result -> {
                    try {
                        removeFromMinio(videoFiles, result.get().objectName());
                    } catch (Exception e) {
                        log.error("删除分块文件失败");
                    }
                }
        );
    }

    /**
     * 保存到数据库
     *
     * @param statObject 源文件信息
     * @param fileMd5    文件md5
     * @param fileName   文件名称
     * @param videoFiles 文件存储桶
     */
    @Transactional
    public void saveToDatabase(StatObjectResponse statObject, String fileMd5, String fileName, String videoFiles, Integer chunkTotal) {
        // 1. 创建媒体文件对象 这里分块的时候已经把contentType破坏了，是默认的
        String mediaType = MediaTypeUtil.getMediaType(ContentInfoUtil.findExtensionMatch(fileName.substring(fileName.lastIndexOf("."))).getMimeType());
        String mergeFilePath = getMergeFilePath(fileMd5, fileName);
        String url = "/" + videoFiles + "/" + mergeFilePath;
        MediaFiles mediaFiles = MediaFiles.builder()
                .id(fileMd5)
                .companyId(1232141425L) // TODO 此处需要从上下文中获取
                .companyName("传智教育")
                .fileId(fileMd5)
                .filename(fileName)
                .fileType(mediaType)
                .fileSize(statObject.size())
                .filePath(mergeFilePath)
                .tags(MediaTagConstant.MEDIA_TAG_VIDEO)
                .bucket(videoFiles)
                .url(url)
                .createDate(LocalDateTime.now())
                .auditStatus(MediaObjectAuditConstant.UN_AUDIT)
                .build();
        int insert1 = mediaFilesMapper.insert(mediaFiles);
        if (insert1 <= 0) throw new IllegalArgumentException();
        // 2. 在数据库中删除分块文件记录
        Integer total = mediaMinioFilesMapper.countByFileId(fileMd5);
        if (!Objects.equals(total, chunkTotal)) throw new IllegalArgumentException();
        mediaMinioFilesMapper.deleteByFileId(fileMd5);
        // 3. 如果不是mp4的视频文件，还要加入待处理任务
        if (mediaType.equals(MediaTypeConstant.MEDIA_TYPE_VIDEO)
                && !statObject.contentType().equals(ContentType.MP4A.getMimeType())) {
            MediaProcess mediaProcess = MediaProcess.builder()
                    .fileId(fileMd5)
                    .bucket(videoFiles)
                    .filePath(mergeFilePath)
                    .status(MediaProcessConstant.PROCESS_NONE)
                    .createDate(LocalDateTime.now())
                    .build();
            int insert2 = mediaProcessMapper.insert(mediaProcess);
            if (insert2 <= 0) throw new IllegalArgumentException();
        }
    }

    /**
     * 获取合并后的文件路径
     *
     * @param fileMd5  文件md5
     * @param fileName 文件名称
     * @return 文件路径
     */
    private String getMergeFilePath(String fileMd5, String fileName) {
        return fileMd5.charAt(0) + "/" + fileMd5.charAt(1) + "/" + fileMd5 + "/" + fileMd5 + fileName.substring(fileName.lastIndexOf('.'));
    }

    /**
     * 预览文件
     *
     * @param id 文件id
     * @return 文件信息
     */
    @Override
    public String preview(String id) {
        MediaFiles mediaFiles = getById(id);
        // 1. 媒资文件不存在
        if (mediaFiles == null) MediaFileNotExistException.cast();
        try {
            FilterInputStream response = minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(mediaFiles.getBucket())
                            .object(mediaFiles.getFilePath())
                            .build());
            response.close();
        } catch (ErrorResponseException e) {
            if (e.errorResponse().code().equals("NoSuchKey")) {
                FileInMinioNotExistException.cast();
            }
        } catch (Exception e) {
            log.error("预览文件失败: {}", e.getMessage());
            throw new RuntimeException(e);
        }
        // 2. 如果是视频文件要看有没有转码，则抛异常
        String url = mediaFiles.getUrl();
        if (mediaFiles.getFileType().equals(MediaTypeConstant.MEDIA_TYPE_VIDEO)
                && (StrUtil.isBlank(url) || !url.endsWith(".mp4")))
            MediaFileNotConvertedException.cast();
        return url;
    }

    /**
     * 删除文件
     *
     * @param id 文件id
     * @return 删除结果
     */
    @Override
    public RestResponse<Boolean> deleteById(String id) {
        MediaFiles mediaFiles = getById(id);
        removeById(id);
        try {
            minioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(mediaFiles.getBucket())
                            .object(mediaFiles.getFilePath())
                            .build());
            return RestResponse.success(true);
        } catch (ErrorResponseException e) {
            if (e.errorResponse().code().equals("NoSuchKey"))
                return RestResponse.success(true);
            else {
                log.error("minio删除文件失败: {}", e.getMessage());
                return RestResponse.error(false, MediaError.FAILED_TO_DELETE_FILE.getErrMessage());
            }
        } catch (Exception e) {
            log.error("minio删除文件失败: {}", e.getMessage());
            return RestResponse.error(false, MediaError.FAILED_TO_DELETE_FILE.getErrMessage());
        }
    }

    /**
     * 删除课程静态化页面
     *
     * @param courseId 课程id
     * @return 删除结果
     */
    @Override
    public RestResponse<Boolean> deleteCourseHtml(Long courseId) {
        String filePath = courseFilePath(courseId);
        mediaFilesMapper.deleteByFilePath(filePath);
        try {
            removeFromMinio(minioProperties.getBucket().getFiles(), filePath);
            return RestResponse.success(true);
        } catch (Exception e) {
            return RestResponse.error(false, MediaError.FAILED_TO_DELETE_FILE.getErrMessage());
        }
    }
}
